温故而知新,保持空杯心态,复习到一半的时间,突然发现了 前端面试之道,从第二道题目开始按学习这本书的路径来
JS 基础2
React/Vue 项目时为什么要在组件中写 key,其作用是什么?
key是给每一个vnode的唯一id,可以
依靠key
,更准确
, 更快
的拿到oldVnode中对应的vnode节点。
key 的作用是为了在 diff 算法执行时更快的找到对应的节点,提高 diff 速度。
Vue 和 React 都是采用 diff 算法来对比新旧虚拟节点,从而更新节点。在 vue 中的 diff 函数,交叉对比中,当新节点跟旧节点 头尾交叉对比
没有结果的时候,会根据新节点的 key 对比旧节点数组中的 key,从而找到对应旧节点。如果没有找到就认为是一个新增节点。而如果没有 key,那么就会采用遍历查找的方法找到对应的旧节点。。一种一个map 映射,另一种是遍历查找。相比之下,map 映射的速度更快。
vue 部分源码:
1 | // oldCh 是一个旧虚拟节点数组 |
[“1”,”2”,”3”].map(parseInt)解析
1 | ['10','10','10','10','10'].map(parseInt); // [10,NaN,2,3,4] |
parseInt(string,radix)
参数:
string
:要被解析的值,如果参数不是一个字符串,则将其转换成字符串。字符串开头的空白符会被忽略。
radix
:一个介于2 和 36 的整数,表示上述字符串的基数。比如参数10 表示我们通常用的十进制数值系统。始终指定该参数可以消除阅读的困惑并且保证转换结果可预测。当未指定基数时,不同的实现会产生不同的结果,通常将值默认是10.
返回值
:返回解析的整数值。如果被解析参数的第一个字符无法被转换为数值类型,则返回 NaN
注意:radix
参数为n 会把第一个参数看做是一个数的 n 进制表示,而返回的值是十进制的。
- 如果字符串string 是以 ‘0x‘ 或者 ‘0X’开头,则基数是16进制
- 如果字符串 string 是以 ’0‘ 开头,基数是8进制或者10进制。ES5 规定用10进制。
- 如果字符串string 以其他任何值开头,则默认是十进制
1 | parseInt(100); // 100 |
map
map() 方法会创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数返回的结果。
1 | const new_array = arr.map(function callback(currentValue[,index[,array]]){ |
callback
回调函数需要三个参数,我们通常只用了第一个参数(其他两个是可选的)
currentValue
:是 callback 数组中正在处理的当前元素。
index
:可选,是 callback 数组中正在处理的当前元素的索引
array
:可选,是 callback map 方法被调用的数组
另外还有 thisAry
:执行 callback 函数使用的 this 值
1 | ['10','10','10','10','10'].map(parseInt); |
那么原题目也是同样的道理。
如果要将字符串数组循环变成数组可使用下面的方法
1 | ['10','10','10','10','10'].map(Number); |
内置类型
JS 中分为7种内置类型,内置类型又分为两大类型:基本类型和对象(Object)[Function,Object,Array,Boolean,Number,String,Date,Error,RegExp,全局对象]
基本类型有:null
,undefined
,string
,number
,boolean
,symbol
其中 JS 的数字类型是浮点类型,没有整型。NaN
也是 number
类型,并且 NaN
等于自身
对于基本类型来说,如果使用字面量的方式,那么这个变量只是个字面量,只有在必要的时候才会被会转换为对应的类型:
1 | let a = 111; // 这只是字面量,不是 number 类型 |
对象(Object )是引用类型,在使用过程中会遇到浅拷贝和深拷贝的问题
1 | let a = {name:'haha'} |
Typeof
typeof 对于基本类型,除了 null 都可以显示正确的类型
1 | typeof 1; //'number' |
typeof 对于对象,除了函数都会显示 Object
1 | typeof []; 'object' |
对于 null 来说,虽然它是基本类型。但是会显示 object ,这是一个存在很久的 Bug。在JS 的最初版本,使用的是32位系统,为了性能问题使用低位储存了变量的内部信息,000
开头代表对象,然后 null
表示全为零,所以将它错误的判断为 object 。虽然现在内部类型判断代码已经更改了,但是这个 bug 却是一直流传下来的。
如果想要获得一个变量的正确类型,可以通过 Object.prototype.call(xx)
,这样就可以获得类似 [object type]
的字符串
1 | let a |
类型转换
转Boolean
在条件判断时,除了 undefined
,null
,false
,NaN
,''
,0
,-0
其他所有值都转为 true,包括所有对象。
对象转基本类型
对象转基本类型时,首先会调用 valueOf
然后调用 toString
,并且这个两个方法是可以重写的
1 | let a = { |
也可以重写 Symbol.toPrimitive
,该方法在转基本类型时调用优先级最高
1 | let a ={ |
四则运算符
加法运算规则:
- 其中一方是字符串类型,另外一方亦然
- 其中一方是数字类型,另外一方亦然
- 只会触发三种类型转换:值 => 原始值, => 数字,=> 字符串
1 | 1 + '1' // 11 |
== 操作符
比较运算 x==y,其中 x 和 y 是值,产生 true 或者 false ,这样的比较按下面的方式进行:
- 若Type(x) 和 Type(y)相同,则
- 若 Type(x) 为 undefined,返回 true
- 若 Type(x) 为 Null,返回 true
- 若 Type(x) 为 Number,则
- 若 x 为 NaN,返回 false
- 若 y 为 NaN,返回 false
- 若 x 与 y 为相等数值,返回 true
- 若 x 为 +0, y 为 -0,返回 true
- 若 x 为 -0, y 为 +0,返回 true
- 返回 false
- 若 Type(x) 为 String,则 x 和 y 为完全相同的字符序列(长度相等且相同字符在相同位置)时返回 true,否则,返回 false
- 若 Type(x) 为 Boolean,当 x 和 y 同为 true 或者同为 false 时返回 true,否则,返回 false。
- 当 x 和 y 为引用同一对象时返回 true,否则返回 false.
- 若 x 为 null 且 y 为 undefined ,返回 true
- 若 x 为 undefined 且 y 为 null ,返回 true
- 若 Type(x) 为 Number,且 Type(y) 为 String,返回比较 x == toNumber(y) 的结果
- 若 Type(x) 为 String 且 Type(y) 为 Number,返回比较 ToNumber(x) == y 的结果
- 若 Type(x) 为 Boolean,返回 比较 ToNumber(x) ==y 的结果
- 若 Type(y) 为 Boolean,返回比较 ToNumber(y) ==x 的结果
- 若 Type(x) 为 String 或者 Number,且 Type(y) 为 Object,返回比较 x==ToPrimitive(y) 的结果
- 若 Type(y) 为 String 或者 Number,且 Type(x) 为 Object,返回比较 y==ToPrimitive(x) 的结果
- 返回 false
toPrimitive 就是对象转基本类型
对照上面的规则,分析下面的案例
1 | [] == ![] // true |
比较运算符
- 如果是对象,就通过 toPrimitive 转换对象
- 如果是字符串,就通过 unicode 字符索引来比较
原型
每个函数都有 prototype 属性,除了 Function.prototype.bind() 该属性指向原型。
每个对象都有 __proto__
属性,指向了创建该对象的构造函数的原型,其实这个属性指向了 [[proptotype]]
,但是 [[proptotype]]
是内部属性,我们并不能访问到,所以使用 __proto__
来访问。对象可以通过 __proto__
来寻找不属于该对象的属性,__proto__
将对象连接起来形成了原型链。
new
- 新生成了一个对象
- 链接到原型
- 绑定 this
- 返回新对象
在调用 new 的过程会发生上面四种事情,下面是自己实现的一个 new
1 | function create(){ |
对于实例对象来说,都是通过 new 产生的,无论是 function Foo(){} 还是 let a = {b:1}
对于创建一个对象来说,更推荐使用字面量的方式来创建对象(无论是性能上还是可读性)。使用 new Object 方式创建对象需要通过作用域链一层层找到 Object,但是使用字面量就没有这个困扰
1 | function Foo(){} |
对于 new 来说,还需要注意下面的运算符优先级
1 | function Foo(){ |
可以看出 new Foo() 优先级大于 new Foo,所以代码可以这样划分执行顺序
1 | new (Foo.getName()) |
对于第一个函数来说,先执行了 Foo.getName 所以结果为1,对于后者来说,先 new Foo() 产生了一个实例,然后通过原型链找到了 Foo 上面的 getName 函数,所以结果为 2
instanceof
instanceof 可以正确判断对象的类型,因为内部机制是通过判断对象的原型链是不是能找到类型的 prototype
我们也可以试着实现:
1 | function instanceof(left,right){ |
this
this 记住几个规则就可以了
1 | function foo(){ |
箭头函数中的 this
1 | function a(){ |
箭头函数其实是没有 this,这个函数中的 this 只取决于外面的第一个不是箭头函数的函数的this 。在上面的例子中,因为调用 a 符合前面代码的第一种情况,所以 this 是 window,并且一旦 this 绑定上下文了,就不会被任何代码改变。
执行上下文
当执行 JS 代码的时候,会产生三种执行上下文
- 全局执行上下文
- 函数执行上下文
- eval 执行上下文
每个执行上下文都有三个重要的属性
- 变量对象(VO),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问
- 作用域链(JS 采用词法作用域,也就是说变量的作用域是在定义时就决定的)
- this
1 | var a = 10; |
对于上述代码,执行栈中有两个上下文:全局上下文和函数 foo 上下文
1 | stack = [ |
对于全局上下文来说,VO 大概是这样的
1 | globalContext.VO === global |
对于 函数 foo 来说,VO 不能被访问,只能访问到活动对象(AO)
1 | fooContext.VO === foo.AO |
对于作用域链,可以把它理解为包含自身变量对象和上级变量对象的列表,通过 [[Scope]] 属性查找上级变量
1 | fooContext.[[Scope]] = [ globalContext.VO ] |
举个例子,var
1 | b(); // hehe |
上面的结果是因为函数和变量提升的原因。通常替身的解释是说将生命的代码移到了顶部,这其实没有什么错误,便于理解。但是更准确的解释应该是:在生成执行上下文时,会有两个阶段。第一个阶段是创建的阶段(具体步骤是创建 VO),JS 解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数放入内存中,变量只声明并且赋值为 undefined,所以在第二个阶段,也就是代码的执行阶段,我们可以直接提前使用。
在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升
1 | b(); //2 |
var 会产生很多错误,所以现在 ES6 中引入了 let,let 不能在声明前使用,但是这并不是常说的 let 不会提升,let 提升了声明但是没有赋值,因为临时死区导致了并不能在声明前使用
对于非匿名立即执行函数需要注意下面的问题
1 | var foo = 1; |
因为当 JS 解释器在遇到非匿名的理解执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo,但是这个值又是只读的,所以对它的赋值并不会生效,所以打印出来的还是这个函数,并且外部的值也没有任何改变。
1 | specialObject = {} |
闭包
闭包的定义很简单:函数 A 返回一个函数 B,并且函数 B 中使用了 函数 A 的变量,函数 B就被称为闭包
1 | function A(){ |
函数 A 中的变量这时候是存储在堆上的,JS 引擎可以通过逃逸分析辨别哪些变量需要存储在对上,哪些需要存储在栈上。
循环中使用闭包解决 var 定义函数的问题
1 | for(var i=1;i<=5;i++){ |
首先,因为 setTimeout 是异步函数,所以回先把所有U型你换全部执行完毕,这时候 i 就是 6了,所以会输出一堆 6.
解决的方法有两种,第一种是使用闭包
1 | for(var i=1;i<=5;i++){ |
第二种是使用 setTimeout 的第三个参数
1 | for(var i=1;i<=5;i++){ |
第三种就是使用 let 定义 i
1 | for(let i=1;i<=5;i++){ |
因为对于 let 来说,会创建一个块级作用域,相当于
1 | { |
深浅拷贝
1 | let a = { |
从上述例子可以看出,如果给一个变量赋值一个对象,那么两者的值会是同一引用,其中一方改变,另一方也会相应改变。
通常在开发中,我们不希望出现这样的问题,我们可以使用浅拷贝来解决这个问题。
浅拷贝
首先可以通过 Object.assign
来解决这个问题
1 | let a = { |
也可通过展开运算符(…)来解决
1 | let a = { |
通常拷贝能解决大部分问题,但是当我们遇到下面的情况就需要使用深拷贝了
1 | let a = { |
浅拷贝只解决了第一层问题,如果接下去的值中还有对象的话,那么两者又享有相同的引用,要解决这个问题,要引入深拷贝。
深拷贝
这个问题通常可以通过 JSON.parse(JSON.stringify(object))
来解决
1 | let a = { |
但是该方法也是有局限性的:
- 会忽略
undefined
- 会忽略
symbol
- 不能序列化函数
- 不能解决循环引用的对象
1 | let obj = { |
在遇到函数、undefined或者 symbol 的时候,该对象也不能正常的序列化
1 | let a = { |
在上述代码中,该方法会忽略掉函数和 undefined
但是在通常情况下,复杂数据是可以序列化的,所以这个函数可以解决大部分问题,并且该函数是内置函数中处理深拷贝性能最快的。数据中含有以上三种情况下,可以使用 lodash 的深拷贝函数。
如果所需要拷贝的对象含有内置类型并且不包括函数的,可以使用 MessageChannel
1 | function structuralClone(obj){ |
我们也可以自己创建一个 deepClone 函数
1 | // 数字 字符串 function 不需要拷贝 |
模块化
在有 Babel 的情况下, 可以直接使用 ES6 的模块化
1 | // file a.js |
CommonJS
CommonJS 是 Node 独有的规范,浏览器中使用就需要用到 Broserify 解析
1 | // a.js |
在上述代码中,module.export 和 export 很容易混淆,看看大致的内部实现
1 | var module = require('./a.js'); |
module.exports 和 exports 用法其实是相似的,但是不能对 exports 直接赋值,不会有任何效果。
对于 CommonJS 和 ES6 的模块化的两者区别是:
- 前者支持动态导入,也就是 require(${path}/xx.js),后者不支持,但是已有提案
- 前者是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
- 前者在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想要更新值,必须重新导入一次。但是后者采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
- 后者会编译成 require/exports 来执行
AMD
AMD 是由 RequireJS 提出的
1 | define(['./a.js','./b.js'],function(a,b){ |
节流和防抖的理解
防抖和节流都是防止函数多次调用。区别在于,假设一个用户一直触发这个函数,且每次触发函数的间隔小于 wait,防抖的情况只会调用一次,而节流的情况会隔一定时间(参数wait)调用函数
防抖
在滚动事件中需要做一个复杂计算或者是实现一个按钮防止第二次点击操作。这些需求都可以通过函数防抖来实现,尤其是第一个需求,如果在频繁的时间回调中做复杂计算,很有可能会导致页面卡顿,不如将多次计算合并为一次计算,只在一个精确点做操作。
通俗化:如果用手指一直按着弹簧,它将不会弹起知道你松手为止
袖珍版的防抖:
1 | // func 是用户传入需要防抖的函数 |
这是一个简单的防抖,但是有缺陷,在于它只能最后调用。一般的防抖会有 immediate 选项,表示是否立即调用。这两者的区别:
- 例如在搜索引擎搜索问题的时候,我们当然希望用户输入完最后一个字才调用查询接口,这个时候用
延迟执行
的防抖函数,它总是在一连串(间隔小于 wait)函数触发之后调用 - 例如用户给项目点 star 的时候,我们希望用户点第一下的时候就去调用接口,并成功之后改变 star 按钮的样子,用户就可以立马得到反馈是否 star 成功了,这个情况使用
立即调用
的防抖函数,它总在第一次调用,并且下一次调用必须和前一次调用的时间间隔大于 wait 才会触发。
带有立即执行的防抖函数
1 | // 这个用来获取当前时间戳 |
总结一下:
- 对于按钮点击来说的实现:如果函数是立即执行的,就理解调用,如果函数是延迟执行的,就缓存上下文和参数,放到延迟函数中执行,一旦开始一个定时器,只要定时器还在,每次点击都会重新计时。一旦定时器时间到了,定时器重置为null,就可以再次点击了。
- 对于延迟函数来说的实现:清除定时器ID,如果是延迟调用就调用函数
节流
防抖和节流本质上是不一样的。防抖是将多次执行变成最后一次执行,节流是将多次执行变成每隔一段时间执行。
通俗化:如果将水龙头拧紧直到水是以水滴的形式流出,那么你会发现隔一段时间,就会有一滴水溜出来。也就是会预先设定一个执行周期,当调用动作的时刻大于等于执行周期则执行该动作,然后进入下一个周期
袖珍版实现:
1 | const throttle = (wait,func)=>{ |
1 | /** |
继承
在 ES5 中,可以使用下面的方式解决继承的问题
1 | function Super(){} |
上面的继承实现思路就是将子类的原型设置为父类的原型
在 ES6 中,可以通过 class 语法糖解决这个问题
1 | class MyDate extends Date{ |
call,apply,bind 区别
call 和 apply 都是为了解决改变 this 的指向,作用都是相同的,只是传参的方式不同,除了第一个参数外,call 可以接受一个参数列表,apply 只接受一个参数数组
1 | let a ={ |
模拟实现 call 和 apply
可以从下面几点来考虑如何实现
- 不传入第一个参数,那么默认可以为 window
- 改变了 this 指向,让新的对象可以执行该函数。
1 | Function.prototype.myCall = function(context){ |
apply 的实现也是类似的
1 | Function.prototype.myApply = function(context){ |
bind 和其他两个方法作用也是一样的,只是该方法会返回一个函数。并且我们可以通过 bind 实现柯里化
模拟实现 bind
1 | Function.prototype.myBind = function(context){ |
Promise 实现
Promise 是 ES6 新增的语法,解决了回调地狱的问题。
可以把 Promise 看成一个状态机。初始状态是 pending 状态,可以通过函数 resolve 和 reject ,将状态转变为 resolved 和 rejected 状态,状态一旦改变就不能再发生变化了。
then 函数会返回一个 Promise 实例,并且该返回值是一个新的实例而不是之前的实例。因为 Promise 规范规定了 pending 状态,其他状态是不可以改变的,如果返回的是同一个实例的话,多个 then 调用就失去意义了。
对于 then 来说,本质上可以把它看成 flatMap
1 | const PENDING = 'pending'; |
Generator 实现
Generator 是 ES6 中新增的语法,和 Promise 一样,都可以用异步来编程。
Generator 函数也可以理解成为一个状态机,封装了多个内部状态。
执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。
形式上,Generator 函数是一个普通函数,但是有两个特征。一个是 function 关键字与函数名之间有一个 星号,二是函数内部使用 yield 表达式,表示不同的内部状态。
1 | // 使用 * 表示这是一个 Generator 函数 |
上述代码可以发现,加上 * 的函数执行后拥有了 next 函数,也就是说函数执行后返回了一个对象。每次调用 next 函数可以继续执行被暂停的代码,下面是 Generator 的简单实现:
1 | // cb 也就是编译的 test 函数 |
一道题目
1 | function *foo(x){ |
分析:
- 首先
Generator
函数调用和普通函数不同,它会返回一个迭代器 - 当执行第一次
next
时,传参会被忽略,并且函数暂停在yield (x + 1)
处,所以返回5 + 1 = 6
- 当执行第二次
next
时,传入的参数等于上一个yield
的返回值,如果你不传参,yield
永远返回undefined
。此时let y = 2 * 12
,所以第二个yield
等于2 * 12 / 3 = 8
- 当执行第三次
next
时,传入的参数会传递给z
,所以z = 13, x = 5, y = 24
,相加等于42
Map、FlatMap 和 Reduce
Map 的作用是生成一个数组,遍历原数组,将每个元素拿出来然后做一些变换然后 append 到新的数组中
1 | [1,2,3].map(v=>v+1); |
Map 有三个参数,分别是当前索引元素,索引,原数组
1 | ['1','2','3'].map(parseInt); |
FlatMap 和 map 的作用几乎是相同的,但是对于多维数组来说,会将原数组降维。可以将 FlatMap 看成是 map + flatten ,目前该函数在浏览器中还不支持。
1 | [1,[2],3].flatMap(v=v+1); |
如果想将一个多维数组彻底的降维,可以这样实现
1 | const flattenDeep = arr => Array.isArray(arr) ? arr.reduce((a,b)=>[...a,...flattenDeep(b)],[]):[arr]; |
Reduce 作用是数组中的值组合起来,最终得到一个值
1 | function a(){ |
async 和 await
一个函数如果加上 async,那么该函数就会返回一个 Promise
1 | async function test(){ |
可以把 async 看成函数返回值使用 Promise.resolve() 包裹了下。
await 只能在 async 函数中使用
1 | function sleep(){ |
上面代码会先打印 finish
然后再打印 object
。因为 await
会等待 sleep
函数 resolve
,所以即使后面是同步代码,也不会先去执行同步代码再来执行异步代码。
async 和 await 相比直接使用 Promise 来说,优势在于处理 then 的调用链,能够更清晰准确的写出代码。缺点在于滥用 await 可能会导致性能问题,因为 await 会阻塞代码,也许之后的异步代码并不依赖前者,但仍然需要等待前者完成,导致代码失去了并发性。
1 | let a = 0; |
- 首先函数 b 执行,在执行到了 await 10 之前 a 的变量还是 0,因为在 await 内部实现了generators ,generators 会保留堆栈中东西,a=0 被保存下来。
- 因为 await 是异步操作,遇到 await 会立即返回一个 pending 状态的 promise 对象,暂时返回执行代码的控制权,使得函数外的代码得以继续执行,所以会先执行 同步代码 console.log(‘1’,a);
- 同步代码之后就是异步代码,将保存下来的值拿出来用,这时候 a = 10
- 后面就是常规的执行代码了
常用的定时器函数
相关面试题:setTimeout、setInterval、requestAnimationFrame 各有什么特点?
requestAnimationFrame
请求动画帧。屏幕刷新频率,也就是屏幕上的图像每秒钟出现的次数,它的单位是赫兹(HZ)。当对着电脑屏幕什么也不做的情况下,显示器也会以每秒60次的频率在不断更新屏幕上的图像。我们之所以感觉不到变化的原因是因为人的眼睛有视觉停留效应,画之间间隔时间只有16.7ms(1000/60),所以我们会觉得屏幕上的图像是静止不动的。
动画的本质就是要让人眼看到图像被刷新而引起的变化的视觉效果,这个变化要以连贯的平滑的方式过渡。
我们在每次刷新前,将图像的位置移动一个像素,这样一来,屏幕每次刷新出来的图像位置都比前一个要差一个像素。因为你会看到图像在移动,由于人眼的视觉停留效应,当前位置的图像停留在大脑的影响还没有消失,紧接着图像又被移到了下一个位置,因为你会看到图像在流畅地移动,这就是视觉效果上形成的动画。
requestAnimationFrame最大的优势就是系统决定的回调函数的执行时机,大概的意思就是回调函数会随着屏幕刷新的频率的变化而产生对应的变化。它能保证回调函数在屏幕每一次的刷新间隔中只执行一次,这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题。
简单的调用
1 | let progress = 0; |
另外它还有两个优势:
- Cpu节能:使用 setTimeout 实现的动画,当页面被隐藏到最小化时,仍然会在后台执行动画人物,由于此时页面处于不可见或者不可用状态,刷新画面也是没有意义的,完全是浪费资源。而 requestAnimationFrame 则完全不同,当页面处理未激活的状态下,该页面的屏幕刷新人物也会被系统暂停,有效节省CPU 开销
- 函数节流:在高频率(resize,scroll)中吗,为了防止在一个刷新间隔内发生多出函数执行,使用 requestAnimationFrame 可以保证每个刷新的间隔内,函数只被执行一次,这样既可以保证流畅性,也能更好的节省函数执行的开销。一个刷新间隔内函数执行多次是没有意义的,因为显示器刷新的频率是一定的,多次绘制不会在屏幕上体现出来。
由于浏览器兼容问题,需要优雅降级做兼容,具体代码,摘自 requestAnimationFrame:
1 | if (!Date.now) { |
setTimeout
设置某个时间后执行某个动作,表示延时执行某个动作
setInterval
设置每隔多久执行某个动作,循环的。setInterval 将注册函数植入 Event Queue,如果前面的任务耗能太久,那么就需要等待。
因为JS 单线程的问题,setTimeout 可能不会按期执行,可以通过代码去修正 setTimeout ,从而使定时器相对准确
1 | let period = 60 * 1000 * 60 * 2; |
Proxy
Proxy 是 ES6 中新增的功能,可以用来自定义对象中的操作
1 | let p = new Proxy(target,handler) |
为什么 0.1 + 0.2 != 0.3
因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题
原生解决方法:
1 | parseFloat((0.1+0.2).toFixed(10)) |
正则表达式
元字符
元字符 | 作用 | ||
---|---|---|---|
. | 匹配任意字符除了换行符和回车符 | ||
[] | 匹配方括号内的任意字符。比如 [0-9] 就可以用来匹配任意数字 | ||
^ | ^9 这样使用匹配以 9 开头,[^9] 这样使用代表不匹配方括号内除了9的字符 | ||
{1,2} | 匹配1到2位字符 | ||
(yck) | 只匹配 yck 相同字符串 | ||
\ | 匹配 \ | 前后任意字符 | |
\ | 转义 | ||
* | 只匹配出现0次及以上 *前的字符 | ||
+ | 只匹配出现1次及以上 +前的字符 | ||
? | ? 之前字符可选 |
修饰符
修饰语 | 作用 |
---|---|
i | 忽略大小写 |
g | 全局搜索 |
m | 多行 |
字符简写
简写 | 作用 |
---|---|
\w | 匹配字母数字或下划线 |
\W | 与上面相反 |
\s | 匹配任意的空白符 |
\S | 与上面相反 |
\d | 匹配数字 |
\D | 与上面相反 |
\b | 匹配单词的开始或结束 |
\B | 与上面相反 |
V8下的垃圾回收机制
V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)分为了新生代和老生代两部分
新生代算法
新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。
在新生代空间中,内存空间分为了两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满的时候,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成将 From 空间和 To 空间互换,这样 GC 就结束了。
老生代算法
老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。
什么情况下对象会出现在老生代空间中:
- 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
- To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中
老生代中的空间很复杂,有如下几个空间
1 | enum AllocationSpace { |
在老生代中,以下情况会先启动标记清除算法:
- 某一个空间没有分块的时候
- 空间中被对象超过一定限制
- 空间不能保证新生代中的对象移动到老生代中
在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行,你可以点击 该博客 详细阅读。
清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
Event Loop
进程和线程
两个名词都是 CPU 工作时间片的一个描述。
进程描述了 CPU 在运行指令以及记载和保存上下文所需的时间,放在应用上来说就代表了一个程序。
线程是进程中更小的单位,描述了一段指令所需要的时间。
在浏览器中,打开一个 Tab 页面,就是创建了一个进程,一个进程里面可以有多个线程,例如渲染线程,JS 引擎线程,HTTP 请求线程。当发起一个请求时,就是在创建一个线程,当请求结束的时候,该线程就可能会被销毁掉。
众所周知,JS 运行时会阻止 UI 渲染,这两个线程是互斥的,因为 JS 可以修改 Dom ,如果在 JS 执行的时候 Ui 线程还在工作,就可能会导致不能正常安全渲染 UI。这也是单线程的一个好处,得益于 JS 是单线程与很像的,可以达到节省呢欧村,节约上下文切换时间,没有锁的问题的好处。
执行栈
可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则
浏览器中的 Event Loop
当遇到异步代码的时候,会被挂起并在需要执行的时候加入到 Task 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要你执行的代码并放入执行栈中执行,所以本质上 JS 中的异步还是同步行为。
不同的任务源会被分配到不同的 Task 队列中,任务源可以分成 微任务(mocrotask) 和 宏任务(macrotask)。在 ES6 规范中,macrotask 被称为 task,microtask 被称为 jobs 。下面举个例子看看代码的执行顺序:
1 | console.log('script start') |
当我们调用 async1 函数的时候,会马上输出 async2 end,并且函数返回一个 Promise,接下来在遇到 await 的时候就让出线程开始执行 async1 外的代码,可以完全把 await 看成是让出线程的标志。
然后当同步代码全部执行完毕以后,就会执行所有的异步代码,那么就会又回到 await 的位置执行返回的 Promise 的 resolve 函数,这又会把 resolve 丢到微任务队列中,接下来执行 then 中的回调,当两个 then 中的回调全部执行完毕后,回到 await 的位置处理返回值,这时候可以看成 Promise.resolve(返回值).then()
,然后 await 后的代码全部被包裹进了 then 的回调中,所以 console.log('async1 end')
会优先执行于 setTimeout
。
微任务包括 process.nextTick
,promise
,MutationObserver
。
宏任务包括 script
, setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
。
Event Loop 执行顺序如下所示:
- 首先执行同步代码,这属于宏任务
- 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
- 执行所有微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是
setTimeout
中的回调函数
Node 中的 Event Loop
涉及的面试题:Node 中 Event Loop 和 浏览器的有什么不同?process.nextTick 执行顺序
Node 中的 Event Loop 分成6个阶段,它们会按照顺序反复运行,每当进入某一个阶段的时候,都会从对应的回调队列取出函数去执行。当队列为空或者执行的回调函数数量达到系统设定的阈值,就会进入下一个阶段
1 | ┌──────────────────────────┐ |
timer
timer 阶段会执行 setTimeout 和 setInerval 回调,并是由 poll 阶段控制的
同样,在 Node 中定时器指定的时间也不是准确的时间,只是尽快执行。
I/O
I/O 阶段会处理上一轮循环中少数未执行的的 I/O 回调
dle,prepare
idle,prepare 阶段内部实现
poll
poll 阶段很重要,在这一阶段中,系统会做两件事情
- 执行到点的定时器
- 执行 poll 队列中的事件
并且当 poll 中没有定时器的情况下,会发现以下两件事情
- 如果poll队列不为空,会遍历回调队列并同步执行,直到队列为空或者系统限制
- 如果poll队列为空,会发生两件事情
- 如果有 setImmediate 需要执行的时候,poll 阶段会停止并且进入到 check 阶段执行 setImmediate
- 如果没有 setImmediate 需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去
当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。
check
check 阶段执行 setImmediate
close callbacks
close callbacks 阶段执行了 close 事件。
首先在有些情况下,定时器的执行顺序其实是随机的
1 | setTimeout(() => { |
对于以上代码来说,setTimeout
可能执行在前,也可能执行在后
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1)
,这是由源码决定的 - 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行
setTimeout
回调 - 那么如果准备时间花费小于 1ms,那么就是
setImmediate
回调先执行了
当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:
当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:
1 | const fs = require('fs') |
在上述代码中,setImmediate
永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate
回调,所以就直接跳转到 check 阶段去执行回调了。
process.nextTick
这个函数是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成之后,如果存在 nextTick 阶段,就会清空队列中的所有回调函数,并优于其他 microtask 执行
1 | setTimeout(() => { |